#!/usr/bin/python

# Python libs
import re, time, os, sys
import urllib2
import logging
from pprint import pformat
from socket import timeout as SocketTimeoutError

# external libs
import feedparser
from BeautifulSoup import BeautifulStoneSoup

try:
    logging.basicConfig(
        filename='iplayer2.log', 
        filemode='w',
        format='%(asctime)s %(levelname)4s %(message)s',
        level=logging.DEBUG
    )
except IOError:
    print "iplayer2 logging to stdout"
    logging.basicConfig(
        stream=sys.stdout,
        level=logging.DEBUG,
        format='iplayer2.py (LOG): %(asctime)s %(levelname)s %(message)s',
    )    
# me want 2.5!!!
def any(iterable):
     for element in iterable:
         if element:
             return True
     return False


# http://colinm.org/blog/on-demand-loading-of-flickr-photo-metadata
# returns immediately for all previously-called functions
def call_once(fn):
    called_by = {}
    def result(self):
        if self in called_by:
            return
        called_by[self] = True
        fn(self)
    return result

# runs loader before decorated function
def loaded_by(loader):
    def decorator(fn):
        def result(self, *args, **kwargs):
            loader(self)
            return fn(self, *args, **kwargs)
        return result
    return decorator

channels_tv_list = [
    ('bbc_one', 'BBC One'), 
    ('bbc_two', 'BBC Two'), 
    ('bbc_three', 'BBC Three'), 
    ('bbc_four', 'BBC Four'),
    ('cbbc', 'CBBC'),
    ('cbeebies', 'CBeebies'),
    ('bbc_news24', 'BBC News Channel'),
    ('bbc_parliament', 'BBC Parliament')
]
channels_tv = dict(channels_tv_list)
channels_radio_list = [
    ('bbc_radio_one', 'Radio 1'), 
    ('bbc_1xtra', '1 Xtra'),
    ('bbc_radio_two', 'Radio 2'), 
    ('bbc_radio_three', 'Radio 3'), 
    ('bbc_radio_four', 'Radio 4'), 
    ('bbc_radio_five_live', '5 Live'), 
    ('bbc_radio_five_live_sports_extra', '5 Live Sports Extra'), 
    ('bbc_6music', '6 Music'), 
    ('bbc_7', 'BBC 7'),
    ('bbc_asian_network', 'Asian Network')
]
channels_radio = dict(channels_radio_list)
categories_list = [
    ('childrens', 'Children\'s'),
    ('comedy', 'Comedy'),
    ('drama', 'Drama'),
    ('entertainment', 'Entertainment'),
    ('factual', 'Factual'),
    ('music', 'Music'),
    ('news', 'News'),
    ('religion_and_ethics', 'Religion & Ethics'),
    ('sport', 'Sport'),
    ('olympics', 'Olympics'),
    ('wales', 'Wales'),
    ('signed', 'Sign Zone')
]
categories = dict(categories_list)

rss_cache = {}

class UnavailableError(Exception):
    def __init__(self, reason=None):
        self.reason = reason
    
    def __str__(self):
        reason = self.reason or '<no reason given>'
        return "Programme unavailable ('%s')" % (reason)

class memoize(object):
    def __init__(self, func):
        self.func = func
        self._cache = {}
    def __call__(self, *args, **kwds):
        key = args
        if kwds:
            items = kwds.items()
            items.sort()
            key = key + tuple(items)
        if key in self._cache:
            return self._cache[key]
        self._cache[key] = result = self.func(*args, **kwds)
        return result
 

def parse_entry_id(entry_id):
    # tag:bbc.co.uk,2008:PIPS:b00808sc
    r = re.compile('PIPS:([0-9a-z]{8})')
    matches = r.findall(entry_id)
    if not matches: return None
    return matches[0]

    

class programme(object):
    '''
    Version-agnostic 
    '''    
    def __init__(self, pid):
        self.pid = pid
        self.meta = {}
        self.items = []

    @call_once
    def read_meta(self):
        logging.info('Read meta for %s...', self.pid)
        xml = self._get_xml()        self._parse_xml(xml)
    
    def _get_xml(self, use_cache=True):        """ Downloads and returns the XML for a PID from the iPlayer site. """
        try:            metaurl = self.metafile_url
            logging.info("Getting XML metadata at URL: %s", metaurl)
            page = urllib2.urlopen(metaurl)            xml = page.read()            page.close()            return xml        except SocketTimeoutError:            logging.error("Timed out trying to download programme XML")            raise

    def _parse_xml(self, xml):
        logging.info('Parsing metadata XML...')
        soup = BeautifulStoneSoup(xml)
        self.meta = {}
        self.items = []

        logging.info('  Found programme: %s', soup.playlist.title.string)
        self.meta['title'] = soup.playlist.title.string
        self.meta['summary'] = soup.playlist.summary.string
        self.meta['updated'] = soup.playlist.updated.string

        if soup.playlist.noitems:
            logging.info('No playlist items: %s', soup.playlist.noitems.get('reason'))
            self.meta['reason'] = soup.playlist.noitems.get('reason')
            
        for item in soup('item'):
            kind = item.get('kind')
            if kind in ['programme', 'radioProgramme']:
                logging.info('  Found item: %s, %s', item.get('group'), item.get('title'))
                i = {}
                i['kind'] = kind
                i['title'] = item.get('title')
                i['identifier'] = item.get('identifier')
                i['group'] = item.get('group')
                i['duration'] = item.get('duration')
                i['broadcast'] = item.broadcast
                self.items.append(i)
        

    def read_connection(self, conn, type, encoding):
        """
        Reads connection info from a conn XML node, returning (protocol,url) pair.
        conn: conn node from BeautifulStoneSoup
        type: stream type (e.g. 'video/mp4')
        encoding: stream encoding (e.g. 'h264')
        """
        conn_application = conn.get('application')
        kind = conn.get('kind')
        
        if kind == 'sis': # http
            href = conn.get('href')
            if type == 'video/mp4' and encoding == 'h264':
                # iPhone, don't redirect or it goes to license failure page
                return 'http', href
            else:
                page = urllib2.urlopen(href)
                page.close()
                url = page.geturl()
                return 'http', page.geturl()
        elif kind in ['level3', 'akamai']: #rtmp
            server = conn.get('server')
            identifier = conn.get('identifier')
            auth = conn.get('authstring')
            params = dict(ip=server, server=server, auth=auth, identifier=identifier)
            return 'rtmp', "rtmp://%(ip)s:1935/ondemand?_fcs_vhost=%(server)s&auth=%(auth)s&aifp=v001&slist=%(identifier)s" % params
        else:
            logging.error("Reading connection, kind '%s' unknown", kind)
            return None, None
            
    def get_streams(self, identifier):
        """
        Returns streams for the given programme identifier as a list of dictionaries.
        identifier: ID of the programme stream (*not* the pid)
        """
        url = "http://www.bbc.co.uk/mediaselector/4/mtis/stream/%s" % identifier
        logging.info("Stream XML URL: %s", url)
        xml_data = urllib2.urlopen(url).read()    
        soup = BeautifulStoneSoup(xml_data)
        medias = soup('media')

        ret = []
        
        for media in medias:
            media_kind = media.get('kind')
            media_type = media.get('type')
            media_encoding = media.get('encoding')
            media_width, media_height = media.get('width'), media.get('height')
            
            conn = media.find('connection')
            protocol, url = self.read_connection(conn, media_type, media_encoding)

            m = { 
                'identifier': identifier,
                'kind': media_kind, 
                'type': media_type, 
                'encoding': media_encoding, 
                'protocol': protocol,
                'url': url }
                
            if media_width and media_height:
                m['width'], m['height'] =  media_width, media_height 
            
            ret.append(m)

        logging.info('Found media: %s', pformat(ret, indent=8))
        return ret

        
    def get_thumbnail(self, size=None):
        """
        Returns the URL of a thumbnail.
        size: '640x360'/'biggest'/'largest' or '512x288'/'big'/'large' or None
        """
        if size in ['640x360', 'biggest', 'largest']:
            return "http://www.bbc.co.uk/iplayer/images/episode/%s_640_360.jpg" % (self.pid)
        elif size in ['512x288', 'big', 'large']:
            return "http://www.bbc.co.uk/iplayer/images/episode/%s_512_288.jpg" % (self.pid)
        else:
            return "http://www.bbc.co.uk/iplayer/images/episode/%s_150_84.jpg" % (self.pid)        

    def get_url(self):
        """
        Returns the programmes episode page.
        """
        return "http://www.bbc.co.uk/iplayer/episode/%s" % (self.pid)

    @loaded_by(read_meta)
    def get_rtmp_url_old(self, item=0):
        """
        Gets the RTMP stream URL using the old method (mediaselector/3)
        item: playlist index
        """
        if not (self.items and self.items[item]['identifier']):
            logging.error("Programme %s, %s not available" % (self.pid, self.title))
            raise UnavailableError(self.meta.get('reason'))
        identifier = self.items[item]['identifier']

        xml_url = "http://www.bbc.co.uk/mediaselector/3/stream/check/iplayer?pid=%s" % identifier
        logging.info("Stream 3 XML URL: %s", xml_url)
        logging.info("Reading %s", xml_url)
        xml_data = urllib2.urlopen(xml_url).read()    
        soup = BeautifulStoneSoup(xml_data)
        
        param_server = soup.stream.server.string
        param_identifier = soup.stream.identifier.string
        param_token = soup.stream.token.string

        logging.info("Server: %s, Identifier: %s, Token: %s", param_server, param_identifier, param_token)

        ident_url = "http://%s/fcs/ident" % param_server
        logging.info("Reading %s", ident_url)
        xml_ident = urllib2.urlopen(ident_url).read()
        soup = BeautifulStoneSoup(xml_ident)

        param_ip = soup.fcs.ip.string
        logging.info("IP: %s", param_ip)
                
        auth = "auth=%s&aifp=v001&slist=%s" % (param_token, param_identifier)
        url = "rtmp://%s:1935/ondemand?_fcs_vhost=%s&%s" % (param_ip, param_server, auth)
        logging.info("Got RTMP URL: %s", url)
        
        return url

    @loaded_by(read_meta)
    def get_media(self, item=0):
        """
        """
        logging.info('Items: %s', self.items)
        if not (self.items and self.items[item]['identifier']):
            logging.error("Programme %s, %s not available" % (self.pid, self.title))
            raise UnavailableError(self.meta.get('reason'))
        identifier = self.items[item]['identifier']
        return self.get_streams(identifier)
        
    def get_stream(self, item=0, preference=None, allow=None):
        """
        Tries to return a stream matching the given parameters. 
        item: playlist index
        preference: preferred stream, one of 'flashhigh', 'flashmed', 'flashlow' and 'mobile'.
        allow: list of allowed streams, in preferred order (e.g. ['flashhigh', 'flashhmed']). Or None for all.
        """
        tep = {}
        tep['flashhigh'] = ('video/mp4', 'h264', 'rtmp')
        tep['flashmed'] = ('video/x-flv', 'vp6', 'rtmp')
        tep['flashlow'] = ('video/mp4', 'spark', 'rtmp')
        tep['mobile'] = ('video/mpeg', 'h264', 'rtmp')
        tep['mp3'] = ('audio/mpeg', 'mp3', 'rtmp')
        tep['real'] = ('audio/real', 'real', 'http')
        
        allowed = tep.keys()
        if allow == None:
            logging.warning('get_stream: no allowed streams specified, allowing all')
            allow = allowed
        else:
            allow = [r for r in allow if r in allowed] # strip unknown
            
        logging.info('  Allowed streams: %s', ','.join(allow))
        
        # sloppily prioritise user preference
        if preference: 
            if preference in allow:
                allow.remove(preference)
            allow.insert(0, preference)

        logging.info('  Preference of streams: %s', ','.join(allow))
        
        media = self.get_media(item=item)
        for t, e, p in [tep[n] for n in allow]:
            m = [m for m in media if m['type'] == t and m['encoding'] == e and m['protocol'] == p]
            if m: 
                logging.info('Found stream: %s', m[0])
                return m[0]
                
        logging.warning('Found no allowable RTMP streams from %s', allow)
        return None
        
    @property
    def metafile_url(self):
        return "http://www.bbc.co.uk/iplayer/playlist/%s" % self.pid
    
    @loaded_by(read_meta)
    def get_title(self):
        return self.meta['title']
    
    @loaded_by(read_meta)
    def get_summary(self):
        return self.meta['summary']

    title = property(get_title)
    summary = property(get_summary)
    thumbnail = property(get_thumbnail)    

programme = memoize(programme)


class feed(object):
    def __init__(self, tvradio, channel=None, category=None, subcategory=None, atoz=None):
        if tvradio in ['tv', 'radio']:
            self.tvradio = tvradio
        else:
            self.tvradio = None
        self.channel = channel
        self.category = category
        self.subcategory = subcategory
        self.atoz = atoz
        
    def create_url(self, listing):
        """
        <channel>/['list'|'popular'|'highlights']
        'categories'/<category>(/<subcategory>)(/['tv'/'radio'])/['list'|'popular'|'highlights']
        """
        assert listing in ['list', 'popular', 'highlights'], "Unknown listing type"
        if self.channel: 
            path = [self.channel]
            if self.atoz:
                path += ['atoz', self.atoz]
            path += [listing]
        elif self.category: 
            path = ['categories', self.category]
            if self.subcategory:
                path += [self.subcategory]
            path += [listing]
            if self.tvradio:
                path += [self.tvradio]
        elif self.atoz:
            path = ['atoz', self.atoz, listing]
            if self.tvradio:
                path += [self.tvradio]
        else:
            assert listing != 'list', "Can't list at tv/radio level'"
            path = [listing, self.tvradio]
          
        return "http://feeds.bbc.co.uk/iplayer/" + '/'.join(path)
    
    def name(self):
        if self.channel:
            assert self.channel in channels_tv or self.channel in channels_radio, 'Unknown channel'
            if self.tvradio == 'tv':
                return channels_tv.get(self.channel, None)
            else:
                return channels_radio.get(self.channel, None)
        if self.atoz:
            return self.atoz.upper()
        return '(Unknown)'
    
    def channels(self):
        if self.channel:
            return None
        if self.tvradio == 'tv': return channels_tv
        if self.tvradio == 'radio': return channels_radio
        return None    
    
    def channels_feed(self):
        if self.channel:
            return None
        if self.tvradio == 'tv': 
            return [feed('tv', channel=ch) for (ch, title) in channels_tv_list]
        if self.tvradio == 'radio': 
            return [feed('radio', channel=ch) for (ch, title) in channels_radio_list]
        return None
    
    def categories(self):
        raise NotImplementedError('Categories not yet supported')
        
    def subcategories(self):
        raise NotImplementedError('Sub-categories not yet supported')
    
    @classmethod
    def is_atoz(self, letter):
        l = letter.lower()
        if len(l) != 1 and l != '0-9': 
            return False
        if l in '0123456789': l = "0-9"
        if l not in 'abcdefghijklmnopqrstuvwxyz0-9':
            return False
        return l
    
    def get(self, child):
        if self.channel: # no children
            return None
        elif self.category:
            return None # subcategories!
        elif self.is_atoz(child):
            return feed(self.tvradio, 
                channel=self.channel,
                category=self.category,
                subcategory=self.subcategory,
                atoz=self.is_atoz(child))

        else:
            if child in channels_tv: return feed('tv', channel=child)
            if child in channels_radio: return feed('radio', channel=child)

    @classmethod
    def read_rss(self, url):
        logging.info('Read RSS: %s', url)
        if url not in rss_cache:
            logging.info('Feed URL not in cache, requesting...')
            progs = feedparser.parse(url)
            d = []
            for entry in progs.entries:
                logging.info('  Entry: %s, %s', entry.id, entry.title)
                pid = parse_entry_id(entry.id)
                p = programme(pid)
                d.append(p)
            logging.info('Found %d entries', len(d))
            rss_cache[url] = d
        else:
            logging.info('RSS found in cache')
        return rss_cache[url]
    
    def popular(self):
        return self.read_rss(self.create_url('popular'))

    def highlights(self):
        return self.read_rss(self.create_url('highlights'))
        
    def list(self):
        return self.read_rss(self.create_url('list'))


tv = feed('tv')
radio = feed('radio')

def test():
    tv = feed('tv')
    print tv.popular()
    print tv.channels()
    print tv.get('bbc_one')
    print tv.get('bbc_one').list()
    print tv.get('bbc_one').channels()
    print tv.categories()
    print tv.get('drama').list()
    print tv.get('drama').get_subcategory('period').list()

if __name__ == '__main__':
    test()
